查看原文
其他

Netflix 工程师分享:如何检测与处理不健康的 JVM

ImportNew ImportNew 2020-01-14

(给ImportNew加星标,提高Java技能)

作者:Josh Snyder,Joseph Lynch

编译:ImportNew/唐尤华

medium.com/@NetflixTechBlog/introducing-jvmquake-ec944c60ba70


Netflix的云数据工程团队运行着各种JVM应用,包括Cassandra、Elasticsearch等等。尽管大多数情况下集群用分配给它们的内存都能稳定运行,但有时“死亡查询”或者数据存储本身的错误会导致内存使用失控,可能触发垃圾回收(GC)循环甚至JVM内存耗尽。


对这种情况我们用jvmkill进行了补救:jvmkill是一种使用JVMTI API的代理,在JVM进程中运行。当JVM内存不足或无法生成线程时,jvmkill会介入并杀死整个进程。我们把jvmkill与Hotspot -XX:HeapDumpOnOutOfMemoryError标志一起使用,以便事后通过堆分析了解为什么会造成资源耗尽。对于应用程序来说,这种处理很合适:JVM内存不足时会无法响应,一旦jvmkill介入systemd会重启失败的进程。


即使有了jvmkill保护,我们仍然会遇到JVM问题,大部分是内存不足造成的问题,当然也不全是如此。这些Java进程反复执行GC,但在暂停期间几乎没有做任何有用的工作。由于JVM并没有耗尽100%资源,因而jvmkill不会发现。另一方面,我们的客户会很快注意到自己的数据存储节点吞吐量下降了近四个数量级。


为了说明这种情况,我们对Cassandra多次加载整个数据集到内存中,以此演示针对Cassandra JVM¹的“死亡查询”:


然后通过jstat和GC日志观察确认机器确实处于GC死亡螺旋中:


cqlsh> PAGING OFF
Disabled Query paging.
cqlsh> SELECT * FROM large_ks.large_table;
OperationTimedOut: errors={}, last_host=some host
cqlsh> SELECT * FROM large_ks.large_table;
Warning: schema version mismatch detected, which might be caused by DOWN nodes; if this is not the case, check the schema versions of your nodes in system.local and system.peers.
Schema metadata was not refreshed.See log for details.


$ sudo -u cassandra jstat -gcutil $(pgrep -f Cassandra) 100ms
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 100.00 100.00 100.00 97.96 95.10 21 8.678 11 140.498 149.176
0.00 100.00 100.00 100.00 97.96 95.10 21 8.678 11 140.498 149.176
0.00 100.00 100.00 100.00 97.96 95.10 21 8.678 11 140.498 149.176


从GC日志中可以清楚地看到20秒以上的暂停重复出现,并且可以使用GCViewer工具用图形化方式分析日志数据:



显然,这种情况下JVM无法满足性能要求,而且恢复的可能性很小。这种死亡螺旋会一直持续,直到值班工程师把受影响的JVM干掉为止。在分页出现太多次以后,我们认为这个问题:


  1. 很容易识别;

  2. 有一个简单的解决方案;

  3. 最好快速干预


换句话说,在人工干预前让机器提前识别处理。


解决方案:主动识别并Kill问题JVM


我们真的很喜欢jvmkill,研究了如何扩展jvmkill满足实际需求。jvmkill对JVMTI ResourceExhausted回调进行了hook。当JVM判定自身资源耗尽时,会向有问题的JVM发送SIGKILL。不幸的是,这种分类过于简单,不能很好地处理故障模式的灰色地带。在这种情况下,JVM耗费了大量时间进行垃圾收集,但是资源还没有耗尽。我们还调研了JVM提供的各种选项,例如GCHeapFreeLimit、GCTimeLimit、OnOutOfMemoryError、ExitOnOutOfMemoryError和CrashOnOutOfMemoryError。最后发现,这些JVM选项要么无法在所有JVM和垃圾收集器上表现一致,要么调整困难或者难于理解,要么根本无法在各种边界情况下使用。由于调整JVM现有的ResourceExhausted classifier是一项几乎不可能完成的任务,因此决定自己构建。


解决方案开始于jvmquake睡前思考:“这个问题到底有多难?”首先想到的是,对于任何工作负载,JVM都应该把大部分时间用来运行程序代码而不是GC暂停。如果程序执行时间占比长期低于某个水平,那么这个JVM显然是不健康的,应该把它Kill掉。


我们通过把JVM GC暂停时间建模成“债务”来实现。如果JVM花了200毫秒进行GC,将增加200毫秒的债务计数。运行程序代码耗费的时间“偿还”积累的债务,直到债务为零时停止。因此,如果上面的程序运行≥200毫秒,那么债务计数器归零。如果JVM花在运行上的时间与GC时间相比超过1:1(即吞吐量>50%),则债务将趋近于零。另一方面,如果吞吐量不到50%,其债务将趋近于无限。这种“债务计数器”方法类似于漏斗算法,用来跟踪程序的吞吐量。GC时间可以看作往漏斗里加水,应用程序运行时间看作水从漏斗里流出:



加入JVM债务计数器后,我们对JVM的健康状况更有信心,最终对那些不健康的JVM采取措施。例如,实际使用jvmquake后的GC螺旋可能看起来像这样:



如果把jvmquake附加在该JVM上,将在虚线处停止。


我们确定了一个可调整的阈值,默认30秒:如果JVM完成GC时债务计数器超过30秒,jvmquake将终止该进程。通过对GarbageCollectionStartGarbageCollectionFinish。JVMTI回调添加hook可以测量这些值。


除了债务阈值外,我们还添加了两个可调参数:


  • runtime_weight:作为运行程序代码时间的权重,以便实现除1:1(吞吐量50%)之外的目标。例如,runtime_weight设为2表示调整目标是1:2(吞吐量33%)。一般情况下,runtime_weight设为x表示比率为1:x(吞吐量=100%/(x+1))。服务器中JVM运行时的吞吐量通常超过95%,因此最低50%吞吐量已经是相当保守了。

  • 采取行动:jvmkill只会向进程发送SIGKILL,但是jvmquake增加了让JVM内存溢出(OOM)功能,并且支持在SIGKILL之前发送任意信号的功能。下一节将解释为什么可能需要执行这些操作。


应用jvmquake之后,对Cassandra节点运行相同的死亡查询,现在可以看到:



和以前一样,JVM开始进入GC死循环,但是这次jvmquake检测到JVM累积了30倍的GC债务(运行时权重4:1)并停止了JVM。与其让JVM一瘸一拐地运行,不如直接kill。


不要丢掉证据!


在使用jvmkill或手动终止JVM时,尽可能使用-XX:HeapDumpOnOutOfMemoryError或jmap收集堆转储文件。这些堆转储文件对于调试内存泄漏找到其根本原因至关重要。不幸的是,jvmquake向没有遇到OutOfMemoryError的JVM发送SIGKILL时,这两种方法都不起作用。解决办法很简单:jvmquake触发时,会激活一个线程,在JVM堆上分配大数组造成内存溢出。这样会触发-XX:HeapDumpOnOutOfMemoryError,并最终Kill该进程。


但是,这里有一个严重的问题:Java堆转储写到磁盘中,如果反复执行自动Kill进程操作,可能会把磁盘填满。因此,我们开始研究生成操作系统core dump而不是JVM堆转储。我们发现,如果可以让一个不健康的JVM发送SIGABRT而不是SIGKILL,那么Linux内核将自动为我们生成一个core dump。这种方法很好,因为它是所有语言运行时的标准配置(包括node.js和Python),最重要的是它能让我们搜集大量core dump和堆转储并写入管道,这样就可以不需要额外的磁盘存储。


Linux生成core dump时,默认会在崩溃的进程工作目录中写入一个名为“core”的文件。为了防止生成core文件导致磁盘空间不足的情况,Linux对core文件大小进行了限制(ulimit -c)。默认的限值为零,不生成core文件。但是,通过使用kernel.core_pattern sysctl,可以指定应用程序通过管道传输core dump(请参阅core手册中“通过管道存储core dump”)。按照这个接口,我们写了一个脚本压缩core文件上传到S3,与崩溃程序相关的元数据也一并上传。


数据流上传完成后,systemd会重新启动发生OOM的JVM。这是一种折衷:把core文件同步上传到S3,不去考虑是否需要在本地存储core文件。实际上,我们能够在不到两分钟的时间内可靠地上传16GB core dump。


告诉我出了什么问题


已经捕获core dump文件,现在可以进行分析找到问题的根源:是查询的问题、硬件问题还是配置问题?大多数情况下,可以通过用到的类和它们的大小来确定。


我们的团队已把jvmquake部署到我们所有Java数据存储中。截至目前,已减轻了数十起事件(每次仅几分钟),并且提高了我们最重要的生产数据库集群的可用性。此外,streaming core dump和脱机转换工具让我们能够调试和修复Cassandra和Elasticsearch数据存储产品中的复杂错误,以便应用程序需要的数据存储保持“始终可用”。我们已经把许多补丁反馈给了社区,期待发现并解决更多问题。


脚注


¹Cassandra 2.1.19大约有20GiB数据和12GiB大小的堆。在实验中,我们关闭了DynamicEndpointSnitch,确保查询可以路由到本地副本,并且关闭分页确保该节点将整个数据集保存在内存中。


推荐阅读

(点击标题可跳转阅读)

在 JVM 中使用透明巨型页

关于 JVM 内存的 N 个问题

为什么 JVM x86 生成的机器代码有 XMM 寄存器?


看完本文有收获?请转发分享给更多人

关注「ImportNew」,提升Java技能

好文章,我在看❤️

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存